SPB Git

Repository Public MIT

page.tsx 10.0 KB 144 lines tsx

1import Link from "next/link";2import { notFound } from "next/navigation";3import { LIMITS } from "@skills/registry-core";4import { ActionForm, Field } from "@/components/action-form";5import { StateBadge } from "@/components/badges";6import { Uploader } from "@/components/uploader";7import { bytes, dateTime } from "@/lib/format";8import { requireSession } from "@/lib/session";9import { canWriteNamespace, getNamespace } from "@/server/auth";10import { csrfTokenFor } from "@/server/crypto";11import { ApiError } from "@/server/errors";12import { getArtifactById, listReleases } from "@/server/registry";13import { artifactStateAction, releaseNotesAction, revokeReleaseAction, updateArtifactAction } from "../../actions";1415export const metadata = { title: "Manage artifact" };16const PW_HINT = "Needed only if you signed in more than 15 minutes ago.";1718export default async function Page({ params }: { params: Promise<{ id: string }> }) {19  const { ctx, principal } = await requireSession();20  let a;21  try {22    a = await getArtifactById(ctx, (await params).id);23  } catch (e) {24    if (e instanceof ApiError) notFound();25    throw e;26  }27  const ns = await getNamespace(ctx, a.namespace);28  if (!ns || !(await canWriteNamespace(ctx, ns))) notFound();29  const releases = await listReleases(ctx, a);30  const uploads = await ctx.sql<{ id: string; version: string; state: string; created_at: Date; rejection: { findings: { code: string; message: string; blocking: boolean }[] } | null }[]>`31    SELECT id, version, state, created_at, rejection FROM uploads WHERE artifact_id = ${a.id} AND state IN ('rejected','quarantined') ORDER BY created_at DESC LIMIT 5`;32  const closed = a.state === "archived" || a.state === "taken_down";3334  return (35    <div className="space-y-10">36      <header>37        <nav aria-label="Breadcrumb" className="mb-3 font-mono text-[12px] text-faint"><Link href="/dashboard/artifacts" className="hover:text-muted">artifacts</Link> / manage</nav>38        <h1 className="break-words font-mono text-2xl font-medium tracking-tight">@{a.namespace}/{a.slug} <StateBadge state={a.state} reason={a.deprecation_reason} /></h1>39        <p className="mt-2 text-sm text-muted">{a.type} · {a.visibility} · <Link className="link" href={`/@${a.namespace}/${a.slug}`}>public page</Link></p>40      </header>4142      <section aria-labelledby="up-h">43        <h2 id="up-h" className="mb-3 text-base font-semibold">Publish a release</h2>44        {closed ? <p className="card p-5 text-sm text-muted">This artifact is {a.state.replace("_", " ")} and accepts no new releases.</p> : <Uploader artifactId={a.id} csrf={csrfTokenFor(principal.csrfSecret)} maxBytes={LIMITS.maxArchiveBytes} />}45        {uploads.length > 0 && (46          <ul className="mt-4 space-y-2 text-sm">47            {uploads.map((u) => (48              <li key={u.id} className="rounded-lg border border-line p-3">49                <p className="font-mono text-[12px]"><span className={u.state === "rejected" ? "text-danger" : "text-warn"}>{u.state}</span> · v{u.version} · {dateTime(u.created_at)}</p>50                {u.rejection?.findings.filter((f) => f.blocking).slice(0, 6).map((f, i) => <p key={i} className="mt-1 text-muted">{f.message}</p>)}51                {u.state === "quarantined" && <p className="mt-1 text-muted">Waiting for a moderator's security review.</p>}52              </li>53            ))}54          </ul>55        )}56      </section>5758      <section aria-labelledby="rel-h">59        <h2 id="rel-h" className="mb-3 text-base font-semibold">Releases</h2>60        {releases.length === 0 ? <p className="text-sm text-muted">No releases yet.</p> : (61          <ul className="space-y-3">62            {releases.map((r) => (63              <li key={r.id} className="card p-5">64                <p className="flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono text-sm">65                  <span className="text-text">v{r.version}</span> <StateBadge state={r.state} reason={r.revocation_reason} />66                  <span className="text-[11px] text-faint">{dateTime(r.published_at)} · {bytes(Number(r.size_bytes))} · sha256 {r.sha256.slice(0, 16)}…</span>67                </p>68                {r.state === "revoked" ? <p className="mt-2 text-sm text-muted">Revoked: {r.revocation_reason}</p> : (69                  <div className="mt-4 grid gap-6 lg:grid-cols-2">70                    <ActionForm action={releaseNotesAction} submit="Save notes">71                      <input type="hidden" name="releaseId" value={r.id} />72                      <label htmlFor={`notes-${r.id}`} className="label">Release notes</label>73                      <textarea id={`notes-${r.id}`} name="releaseNotes" rows={3} defaultValue={r.release_notes ?? ""} maxLength={20000} className="input py-2" />74                      <p className="mt-1.5 text-xs text-muted">Notes are the only editable part of a release.</p>75                    </ActionForm>76                    <details>77                      <summary className="cursor-pointer text-sm text-danger">Revoke this version…</summary>78                      <ActionForm action={revokeReleaseAction} submit="Revoke release" danger className="mt-3 space-y-3" confirm={`Revoke v${r.version}? Downloads will be blocked permanently.`}>79                        <input type="hidden" name="releaseId" value={r.id} />80                        <Field label="Public reason" name="reason" required minLength={5} maxLength={1000} placeholder="e.g. Contains a leaked credential" />81                        <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirm" required className="mt-1" /> I understand revocation cannot be undone.</label>82                        <Field label="Password" name="password" type="password" autoComplete="current-password" hint={PW_HINT} />83                      </ActionForm>84                    </details>85                  </div>86                )}87              </li>88            ))}89          </ul>90        )}91      </section>9293      <section aria-labelledby="meta-h" className="max-w-2xl">94        <h2 id="meta-h" className="mb-3 text-base font-semibold">Listing metadata</h2>95        <ActionForm action={updateArtifactAction} submit="Save metadata" className="card space-y-4 p-6">96          <input type="hidden" name="id" value={a.id} />97          <input type="hidden" name="previousVisibility" value={a.visibility} />98          <Field label="Summary" name="summary" required minLength={10} maxLength={300} defaultValue={a.summary} hint="Replaced by the manifest description when a newer release becomes latest." />99          <Field label="Source repository" name="repository" type="url" defaultValue={a.repository_url ?? ""} hint="Changing it clears any source verification." />100          <div className="grid gap-4 sm:grid-cols-2">101            <Field label="Homepage" name="homepage" type="url" defaultValue={a.homepage_url ?? ""} />102            <Field label="Issue tracker" name="issues" type="url" defaultValue={a.issues_url ?? ""} />103          </div>104          <Field label="Keywords" name="keywords" defaultValue={a.keywords.join(", ")} hint="Up to 12, comma-separated, lowercase." />105          <Field label="Visibility" name="visibility"><select id="f-visibility" name="visibility" defaultValue={a.visibility} className="input"><option value="private">Private</option><option value="unlisted">Unlisted (link only)</option><option value="public">Public</option></select></Field>106          <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirmPublic" className="mt-1" /> I confirm the change of visibility (required when exposing the artifact).</label>107          <Field label="Password" name="password" type="password" autoComplete="current-password" hint={`For visibility changes. ${PW_HINT}`} />108        </ActionForm>109      </section>110111      <section aria-labelledby="life-h" className="max-w-2xl">112        <h2 id="life-h" className="mb-3 text-base font-semibold">Lifecycle</h2>113        <div className="card space-y-6 p-6">114          {a.state === "deprecated" ? (115            <ActionForm action={artifactStateAction} submit="Remove deprecation">116              <input type="hidden" name="id" value={a.id} /><input type="hidden" name="action" value="undeprecate" />117              <p className="text-sm text-muted">Deprecated: {a.deprecation_reason}</p>118            </ActionForm>119          ) : !closed && (120            <ActionForm action={artifactStateAction} submit="Deprecate" danger className="space-y-3">121              <input type="hidden" name="id" value={a.id} /><input type="hidden" name="action" value="deprecate" />122              <Field label="Deprecation reason (public)" name="reason" required minLength={5} maxLength={1000} placeholder="Use @namespace/other instead" />123              <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirm" required className="mt-1" /> Show a deprecation warning to everyone.</label>124              <Field label="Password" name="password" type="password" autoComplete="current-password" hint={PW_HINT} />125            </ActionForm>126          )}127          {!closed && (128            <details>129              <summary className="cursor-pointer text-sm text-danger">Archive this artifact…</summary>130              <ActionForm action={artifactStateAction} submit="Archive" danger className="mt-3 space-y-3" confirm="Archive this artifact? It becomes read-only and leaves search results.">131                <input type="hidden" name="id" value={a.id} /><input type="hidden" name="action" value="archive" />132                <Field label="Reason" name="reason" maxLength={1000} />133                <label className="flex items-start gap-2 text-sm text-muted"><input type="checkbox" name="confirm" required className="mt-1" /> Archive: no new releases, hidden from listings. Existing releases stay downloadable by direct link.</label>134                <Field label="Password" name="password" type="password" autoComplete="current-password" hint={PW_HINT} />135              </ActionForm>136            </details>137          )}138          {closed && <p className="text-sm text-muted">This artifact is {a.state.replace("_", " ")}.</p>}139        </div>140      </section>141    </div>142  );143}144
Files main
Menu

Theme